waka.dev

2020-12-28 RBSの型情報を使ってリクエストパラメータをランタイムで型チェックしてみる

Ruby3 がリリースされてめでたい!
RBS という型定義ファイルでコードチェックする機能がデフォルトで使えるようになった(実際は Steep という型チェックライブラリと合わせて使う)。

今後主要なリポジトリで RBS ファイルが提供されて、エディタサポートが効いたりすると生産性がめちゃ変わりそう。
ということでいくつか実験してみたいことがあったので、触ってみてる。

※ あくまでキャッチアップも兼ねた実験です

普段Railsアプリケーションを触っていて、クラッシュレポートで見かける No1 は NoMethodError。とりわけリクエストパラメータに関する nil アクセスが非常に多い所感があります。
何が来るか分からんということと、それらに対するバリデーション、特にネストされたパラメータになればなるほど nil アクセスによる NoMethodError が多くなる印象。
ActiveModel使ってリクエストパラメータを扱うフォームレイヤみたいなものを作ることも多いと思うが、ネストしてるとバリデーション書いていくの大変なんですよね・・

Java みたいな言語だとリクエストパラメータを POJO で定義してコントローラで型チェック済なオブジェクトを受け取れるおかげでだいぶ楽できる。
あれを Ruby でも出来ないかなー、つまりはランタイムで RBS の型定義を利用して定義と異なるパラメータが来たら落とすということが出来ないか実験してみた。

こんな感じでリクエストパラメータを RBS ファイルで定義したとする。

class UsersController
  # ex: { name: "bob", age: 20, job: { title: "engineer", grade: 5 }, skill: ["ruby"] }
  class CreateRequest < Struct[String | Integer | Job | Array[String]]
    attr_reader name: String
    attr_reader age: Integer
    attr_reader job: Job
    attr_reader skill: Array[String]

    def initialize: (name: String, age: Integer, job: Job, skill: Array[String]) -> void
  end

  class Job < Struct[String | Integer]
    attr_reader title: String
    attr_reader grade: Integer

    def initialize: (title: String, grade: Integer) -> void
  end
end

RBS のリポジトリの README を読むと分かるが、RBS ファイルに定義したクラスの型情報を以下のコードで取ることができる。

loader = RBS::EnvironmentLoader.new
loader.add(path: signature_file_path)
rbs_env = RBS::Environment
  .from_loader(loader)
  .resolve_type_names
builder = RBS::DefinitionBuilder.new(env: rbs_env)

instance = builder.build_instance(
  RBS::TypeName.new(
     name: :CreateRequest,
     namespace: RBS::Namespace.new(absolute: true, path: [:UsersController])
  )
)
puts instance.instance_variables
=> UsersController::CreateRequest struct  attributes の型情報が取れる

これを少しごにょってみると、いい感じに struct の attributes とその型が何かを持ったハッシュを作ることができる。

kv_pairs = instance.instance_variables.map do |key, value|
  k = key.to_s.delete("@").to_sym
  name = value.type.name
  [k, name.name]
end
kv_pairs.to_h
=> {:name=>:String, :age=>:Integer, :job=>{:title=>:String, :grade=>:Integer}, :skill=>:Array}

こいつとリクエストパラメータを比較することで、いい感じにチェックできるんじゃないかということで、gem にしてみた。

waka/typed_params - GitHub

定義と異なる構造のパラメータがきたらこんな感じでエラーが取れる。

全然関係ないけど、初めての main ブランチ。。
rbs_gem をインストールできれば動くので、Ruby 2.6以上で動くはず。
typed_params というメソッドを通すと、RBS ファイルで型チェックして、こんな感じで ActionController で撃ち落とすことができそう。

class UsersController
  include TypedParams

  rescue_from TypeParams::InvalidTypeError, with: :request_error_handler

  def index
    index_params = typed_params(params, 'Users::IndexRequest')
    response = some_resources(index_params)
    render json: response status: :ok
  end

  private

  def request_error_handler
    render json: { error: 'invalid request' }, status: :bad_request
  end
end

例えば、

  • value の型が違う(String <-> Integer 等)
  • ネストされた JSON が不適切
  • key が不足している
  • 想定外の key が渡ってきている
    という場合に InvalidTypeError を発生させるので resque_from で拾えばよい。

最初ネストしたハッシュパラメータの場合どう型チェックできるかなとガチャガチャやっていて、Struct で定義すればネストしていてもチェックできるやんということに気づいた。
結構便利に使えそうな気はするが、意外と RBS ファイルの読み込みに時間がかかるので、気軽にやるのはまだ厳しい感じがする。
また、割と頻繁に変更されていきそうな RBS のクラスを結構触っているのでメンテも大変そう。
狙った RBS::Definition を取るまでが結構大変なので、良い感じの API が欲しくなってくる。